文章目录
  1. 1. 为什么要内存对齐
    1. 1.1. 内存存取粒度
    2. 1.2. 内存对齐基础
    3. 1.3. 懒惰的处理器
  2. 2. 内存对齐的例子
    1. 2.1. #pragma pack()
    2. 2.2. 结构体内成员如何找出自己的位置

为什么要内存对齐

简单的说内存对齐能够提高 cpu 读取数据的速度,减少 cpu 访问数据的出错性(有些 cpu 必须内存对齐,否则指针访问会出错)。

对于所有直接操作内存的程序员来说,数据对齐都是很重要的问题.数据对齐对你的程序的表现甚至能否正常运行都会产生影响.就像本文章阐述的一样,理解了对齐的本质还能够解释一些处理器的”奇怪的”行为.

内存存取粒度

程序员通常倾向于认为内存就像一个字节数组.在C及其衍生语言中,char * 用来指代”一块内存”,甚至在JAVA中也有byte[]类型来指代物理内存.

然而,你的处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存.我们将上述这些存取单位称为内存存取粒度.

高层(语言)程序员认为的内存形态和处理器对内存的实际处理方式之间的差异产生了许多有趣的问题.如果你不理解内存对齐,你编写的程序将有可能产生下面的问题,按严重程度递增:

  1. 程序运行速度变慢
  2. 应用程序产生死锁
  3. 操作系统崩溃
  4. 你的程序会毫无征兆的出错,产生错误的结果

内存对齐基础

为了说明内存对齐背后的原理,我们考察一个任务,并观察内存存取粒度是如何对该任务产生影响的.这个任务很简单:先从地址0读取4个字节到寄存器,然后从地址1读取4个字节到寄存器.

首先考察内存存取粒度为1byte的情况:

这迎合了那些天真的程序员的观点:从地址0和地址1读取4字节数据都需要相同的4次操作.现在再看看存取粒度为双字节的处理器(像最初的68000处理器)的情况:

从地址0读取数据,双字节存取粒度的处理器读内存的次数是单字节存取粒度处理器的一半.因为每次内存存取都会产生一个固定的开销,最小化内存存取次数将提升程序的性能.

但从地址1读取数据时由于地址1没有和处理器的内存存取边界对齐,处理器就会做一些额外的工作.地址1这样的地址被称作非对齐地址.由于地址1是非对齐的,双字节存取粒度的处理器必须再读一次内存才能获取想要的4个字节,这减缓了操作的速度.

最后我们再看一下存取粒度为4字节的处理器(像68030,PowerPC® 601)的情况:

在对齐的内存地址上,四字节存取粒度处理器可以一次性的将4个字节全部读出;而在非对齐的内存地址上,读取次数将加倍.既然你理解了内存对齐背后的原理,那么你就可以探索该领域相关的一些问题了.

懒惰的处理器

处理器对非对齐内存的存取有一些技巧.考虑上面的四字节存取粒度处理器从地址1读取4字节的情况,你肯定想到了下面的解决方法:

处理器先从非对齐地址读取第一个4字节块,剔除不想要的字节,然后读取下一个4字节块,同样剔除不要的数据,最后留下的两块数据合并放入寄存器.这需要做很多工作.

有些处理器并不情愿为你做这些工作.最初的68000处理器的存取粒度是双字节,没有应对非对齐内存地址的电路系统.当遇到非对齐内存地址的存取时,它将抛出一个异常.最初的Mac OS并没有妥善处理这个异常,它会直接要求用户重启机器.悲剧.

随后的680x0系列,像68020,放宽了这个的限制,支持了非对齐内存地址存取的相关操作.这解释了为什么一些在68020上正常运行的旧软件会在68000上崩溃.这也解释了为什么当时一些老Mac编程人员会将指针初始化成奇数地址.在最初的Mac机器上如果指针在使用前没有被重新赋值成有效地址,Mac会立即跳到调试器.通常他们通过检查调用堆栈会找到问题所在.

所有的处理器都使用有限的晶体管来完成工作.支持非对齐内存地址的存取操作会消减”晶体管预算”,这些晶体管原本可以用来提升其他模块的速度或者增加新的功能.以速度的名义牺牲非对齐内存存取功能的一个例子就是MIPS.为了提升速度,MIPS几乎废除了所有的琐碎功能.

PowerPC各取所长.目前所有的PowPC都硬件支持非对齐的32位整型的存取.虽然牺牲掉了一部分性能,但这些损失在逐渐减少.另一方面,现今的PowPC处理器缺少对非对齐的64-bit浮点型数据的存取的硬件支持.当被要求从非对齐内存读取浮点数时,PowerPC会抛出异常并让操作系统来处理内存对齐这样的杂事.软件解决内存对齐要比硬件慢得多.

内存对齐的例子

在实际的编程中(以 C 语言为例),编译器都会为你自动对齐的。

在结构中,编译器为结构的每个成员按其自身的自然对界(alignment)条件分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同

例如,下面的结构各成员空间分配情况(假设对齐方式大于2字节,即#pragma pack(n), n = 2,4,8…下文将讨论#pragmapack()):

1
2
3
4
5
6
7
8
struct test
{
char x1;
short x2;
float x3;
char x4;
};

结构的第一个成员x1,其偏移地址为0,占据了第1个字节。第二个成员x2为short类型,其起始地址必须2字节对界,即偏移地址是2的倍数。因此,编译器在x2和x1之间填充了一个空字节,将x2放在了偏移地址为2的位置。结构的第三个成员x3和第四个成员x4恰好落在其自然对界地址上,在它们前面不需要额外的填充字节。在test结构中,成员x3要求4字节对界,是该结构所有成员中要求的最大对界单元,因而test结构的自然对界条件为4字节,整个结构体的大小是最大对界单元大小的整数倍(结构体内部有结构体时也遵循这个规则,下文将提到),编译器在成员x4后面填充了3个空字节。整个结构所占据空间为12字节。

#pragma pack()

该预处理指令用来改变对齐参数。在缺省情况下,C编译器为每一个变量或数据单元按其自然对界条件分配空间。一般地,可以通过下面的方法来改变缺省的对齐参数:

使用伪指令#pragma pack (n),C编译器将按照n字节对齐。
使用伪指令#pragma pack (),取消自定义字节对齐方式。

也可以写成:

#pragma pack(push,n)
#pragma pack(pop)

#pragma pack (n)表示每个成员的对齐单元不大于n(n为2的整数次幂)。这里规定的是上界,只影响对齐单元大于n的成员,对于对齐字节不大于n的成员没有影响。其实从字面意思,pack是“包裹,打包”的意思,#pragma pack(n)规定n个字节是一个“包裹”,个人认为实在不理解的话可以认为处理器一次性可以从内存中读/写n个字节,这样好理解。对于大小小于n的成员,当然是按照自己的对齐条件对齐,因为不论怎么放都可以一次性取出。对于对齐条件大于n个字节的成员,成员按照自身的对齐条件对齐和按照n字节对齐需要相同的读取次数,但按照n字节对齐节省空间,何乐而不为呢。

结构体内成员如何找出自己的位置

看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma pack(8)
struct s1
{
short a;
long b;
};
struct s2
{
short c;
s1 d;
long long e;
};
#pragma pack()

成员对齐有一个重要的条件:每个成员分别对齐。即每个成员按自己的方式对齐.

也就是说上面虽然指定了按8字节对齐,但并不是所有的成员都是以8字节对齐。其对齐的规则是,每个成员按其类型的对齐参数(通常是这个类型的大小)和指定对齐参数(这里是8字节)中较小的一个对齐。并且结构的长度必须为所用过的所有对齐参数的整数倍(只要是最大的对齐参数的整数倍即可),不够就补空字节(视编译器而定)。

S1中,成员a是2字节默认按2字节对齐,指定对齐参数为8,这两个值中取2,a按2字节对齐;成员b是4个字节,默认是按4字节对齐,这时就按4字节对齐,a后补2个字节后存放b,所以sizeof(S1)应该为8。8是4的倍数,满足上述的第3条规则。

S2中,c和S1中的a一样,按2字节对齐,而d是个结构,它是8个字节,它按什么对齐呢?对于结构来说,它的默认对齐方式就是该结构定义(声明)时它的所有成员使用的对齐参数中最大的一个,S1的是4,小于指定的8。所以成员d就是按4字节对齐,c后补2个字节,后面是8个字节的结构体d。成员e是8个字节,它是默认按8字节对齐,和指定的一样,所以它对到8字节的边界上,这时,已经使用了12个字节了,所以d后又补上4个字节,从第16个字节开始放置成员e。这时,长度为24,已经可以被最大对齐参数8(成员e按8字节对齐)整除。这样,一共使用了24个字节。

如果上面那段代码,如果去掉 #pragma pack 的话,应该是这样的: (未完待续)

原始出处:
为什么要内存对齐
关于内存对齐

文章目录
  1. 1. 为什么要内存对齐
    1. 1.1. 内存存取粒度
    2. 1.2. 内存对齐基础
    3. 1.3. 懒惰的处理器
  2. 2. 内存对齐的例子
    1. 2.1. #pragma pack()
    2. 2.2. 结构体内成员如何找出自己的位置